<?php
/**
 * CWS|中文分词
 * @note Chinese Words Segmentation
 * @author wsfuyibing <websearch@163.com>
 * @source [PATH]project/module/cws.php
 * @date 2014-07-06
 */
class CwsModule{
	/**
	 * 属性描述
	 * @property string $_DICT_CHARSET, 词典编码
	 * @property string $_DICT_MODE, 词典模式(file, redis, mysql)
	 * @property string $_DICT_MODE_FILE, 词典存放位置
	 * @property string $_DICT_MODE_REDIS, 词典Redis键名前缀
	 * @property string $_SEG_MODE, 分词模式[MAX最大|MIN最小]
	 * @property array $_SEG_PUNCT, 忽略中文标签符号
	 * @property int $_SEG_LEN_MIN, 分词最小长度(至少几个字才视为一个词)
	 * @property int $_SEG_LEN_MAX, 分词最大长度(最多几个字视为一个词)
	 */
	private $_DICT_CHARSET = 'UTF-8';
	private $_DICT_MODE = 'mysql';
	private $_DICT_MODE_FILE = 'setting/cws/default/dict';
	private $_DICT_MODE_REDIS = 'cwsdict';
	private $_SEG_MODE = 'MAX';
	private $_SEG_PUNCT = array(); 
	private $_SEG_LEN_MIN = 1;
	private $_SEG_LEN_MAX = 0;
	/**
	 * 构造方法/初始化分词配置
	 */
	public function __construct(){
		/**
		 * 词典编码
		 */
		function_exists('mb_internal_encoding') || E('PHP_EXTENSION_DISABLED', array('module' => 'mbstring'));
		function_exists('iconv') || E('PHP_EXTENSION_DISABLED', array('module' => 'iconv'));
		$_DICT_CHARSET = S('CWS_DICT_CHARSET'); $_DICT_CHARSET && $this->_DICT_CHARSET = strtoupper($_DICT_CHARSET);
		mb_internal_encoding($this->_DICT_CHARSET);
		/**
		 * 词典模式
		 */
		$_DICT_MODE = S('CWS_DICT_MODE'); $_DICT_MODE && $this->_DICT_MODE = strtolower($_DICT_MODE);
		$_DICT_MODE_FILE = S('CWS_DICT_MODE_FILE'); $_DICT_MODE_FILE && $this->_DICT_MODE_FILE = & $_DICT_MODE_FILE;
		$_DICT_MODE_REDIS = S('CWS_DICT_MODE_REDIS'); $_DICT_MODE_REDIS && $this->_DICT_MODE_REDIS = & $_DICT_MODE_REDIS;
		/**
		 * 中文标点符号
		 */
		$_SEG_PUNCT = S('CWS_SEG_PUNCT'); is_array($_SEG_PUNCT) && $this->_SEG_PUNCT = & $_SEG_PUNCT;
		/**
		 * 分词长度
		 */
		$_SEG_LEN_MIN = S('CWS_SEG_LEN_MIN'); is_numeric($_SEG_LEN_MIN) && $_SEG_LEN_MIN > 1 && $this->_SEG_LEN_MIN = (int) $_SEG_LEN_MIN;
		$_SEG_LEN_MAX = S('CWS_SEG_LEN_MAX'); is_numeric($_SEG_LEN_MAX) && $_SEG_LEN_MAX > 1 && $this->_SEG_LEN_MAX = (int) $_SEG_LEN_MAX;
	}

	/**
	 * 删除词汇
	 * @param int|string $p, 待删除词汇编号(数字有可能是词汇, 必须传ID值)
	 * @example M('cws')->del(1), 删除ID为1的词汇
	 * @example M('cws')->del('测试'), 删除"测试"词汇
	 * @return array, 被删除词汇在表cws_dict记录
	 * @return void, 被删除词汇不存在
	 */
	public function del($p){
		$w = array();
		/**
		 * 数字参数
		 */
		if (preg_match("/^([0-9]+)$/", $p)){
			$w['id'] = (int) $p;
		}
		/**
		 * 词汇参数
		 */
		else if (is_string($p)) {
			$w['hash'] = $this->_hash($p);
		}
		/**
		 * 已传参数
		 */
		if (count($w)){
			$word = wsd::R('cws_dict', $w, true); if (wsc::isarr($word, 'id', '>0')){
				wsd::D('cws_dict', array('id' => $word['id']));
				/**
				 * 删除仅操作MySQL记录
				 * @note 如果使用的不是MySQL模式, 就不准确了
				 *       所以, 删除完了需要按模式同步删除
				 */
				switch ($this->_DICT_MODE){
					case 'file' : $this->del_file($word); break;
					case 'redis' : $this->del_redis($word); break;
				}
				/**
				 * 返回记录
				 */
				return $word;
			}
		}
	}
	/**
	 * 删除XDB词汇
	 * @param array $p, 表cws_dict单条记录
	 * @return void, 无返回
	 */
	private function del_file(& $p){
		/** 以后再说 */
	}
	/**
	 * 删除Redis词汇
	 * @param array $p, 表cws_dict单条记录
	 * @return void, 无返回
	 */
	private function del_redis(& $p){
		nosql::app('del', $this->_DICT_MODE_REDIS.':'.$p['hash']);
	}

	/**
	 * 保存词汇(添加/修改)
	 * @param string|array $p, 必须的参数字段
	 *                     string word, 词汇
	 *                     float weight, 权重基数
	 *                     string attr, 词性(名词、形容词等)
	 * @example M('cws')->save('中国')
	 * @example M('cws')->save(array('word' => '中国', 'weight' => 6.26, 'attr' => 'ns'))
	 * @return array, 最近操作的表cws_dict一条记录
	 */
	public function save($p){
		/**
		 * 参数重置
		 */
		is_array($p) || $p = array(
			'word' => $p
		);
		/**
		 * 字段初始化
		 */
		$f = array('word' => wsc::isarr($p, 'word'));
		/**
		 * 词汇是不能为空的
		 */
		if ($f['word'] == ''){
			return;
		}
		/**
		 * 权重基数
		 */
		$f['weight'] = wsc::isarr($p, 'weight');
		$f['weight'] = is_numeric($f['weight']) && $f['weight'] > 0 ? sprintf('%.06f', $f['weight']) : 0;
		/**
		 * 词性
		 */
		$f['attr'] = wsc::isarr($p, 'attr'); 
		$f['attr'] && preg_match("/^([a-z]+)$/i", $f['attr']) || $f['attr'] = '#';		
		/**
		 * 操作[添加|修改]
		 */
		$h = $this->_hash($f['word']);
		$q = wsd::R('cws_dict', array('hash' => $h), true);
		/**
		 * 修改词汇
		 */
		if (wsc::isarr($q, 'id', '>0')){
			wsd::U('cws_dict', $f, array('id' => $q['id']));
			$word = array_merge($q, $f);
		}
		/**
		 * 添加新词汇
		 */
		else {
			$word = wsd::C('cws_dict', array_merge(array('hash' => $h), $f), 'id');
		}
		/**
		 * 保存仅操作MySQL记录
		 * @note 如果使用的不是MySQL模式, 就不准确了
		 *       所以, 保存完了需要按模式同步;
		 *       如果中途切换了分词模式，就需要全记录同步了
		 */
		switch ($this->_DICT_MODE){
			case 'file' : $this->save_file($word); break;
			case 'redis' : $this->save_redis($word); break;
		}
		/**
		 * 返回结果
		 */
		return $word;
	}
	/**
	 * 保存XDB词汇
	 * @param array $p, 表cws_dict单条记录
	 * @return void, 无返回
	 */
	private function save_file($p){
		/** 以后再说 */
	}
	/**
	 * 保存Redis词汇
	 * @param array $p, 表cws_dict单条记录
	 * @return void, 无返回
	 */
	private function save_redis(& $p){
		nosql::app('set', $this->_DICT_MODE_REDIS.':'.$p['hash'], $p['weight']);
	}

	/**
	 * 搜索
	 * @note 按用户内容返回优化后的关键词
	 * @param string 用户搜索内容
	 * @return array, 返回分词(同seg返回)
	 */
	public function search($p, $s = 'WEIGHT'){
		/**
		 * 最小分词
		 */
		$w = $this->seg($p, 'MIN');
		/**
		 * 去除相同项
		 * @note 如"中国人"词汇有"中国"词汇时, 去除
		 * @note 如"研究生"词汇有"研究"词汇时, 去除
		 */
		foreach ($w as $ak => $av){
			foreach ($w as $bk => $bv){
				if ($ak != $bk && $av['length'] < $bv['length'] && preg_match("/".$av['word']."/", $bv['word'])){
					unset($w[$ak]);
				}
			}
		}
		/**
		 * 权重排序
		 */
		$r = array(); foreach ($w as $wv){
			$wk = sprintf('%010d', $wv['weight'] * 1000000).'_'.$wv['hash'];
			$r[$wk] = $wv;
		} krsort($r); reset($r);
		/**
		 * 返回分词
		 */
		return $r;
	}
	
	/**
	 * 执行分词
	 * @param string $s, 待分词的内容
	 * @example M('cws')->seg("硕士研究生产技术")
	 * @return mixed array, 返回分词结果
	 * @example return array(
	 *     '硕士' => array(
	 *         'hash' => 'm1....v2',  // 唯一哈希值
	 *         'times' => 1, // 词频
	 *         'weight' => 3.14 // 权重基数
	 *     )
	 * )
	 * @property int $seg_count_line, 分词总行数
	 * @property int $seg_count_seek, 分词检索次数
	 * @property int $seg_count_word, 分词后返回多少个有效词汇
	 */
	public $seg_count_line = 0;
	public $seg_count_seek = 0;
	public $seg_count_word = 0;
	public function seg($s, $mode = 'MAX'){
		/**
		 * 分词模式
		 * @note MIN 如"中国人"将返回"中国人"词汇
		 * @note MAX 如"中国人"将返回"中国"、"中国人"词汇
		 */
		$this->_SEG_MODE = is_string($mode) && strtoupper($mode) === 'MIN' ? 'MIN' : 'MAX';
		/**
		 * 初始化统计数值
		 */
		$this->seg_count_line = 0;
		$this->seg_count_seek = 0;
		$this->seg_count_word = 0;
		/**
		 * 去除HTML
		 */
		$s = preg_replace("/\<\/([_a-z0-9]+)\>/i", "</\\1> ", $s);
		$s = preg_replace(array("/[\r|\n|\t]/"), " ", $s);
		$s = strip_tags($s);
		/**
		 * 去除标点
		 * @note 英文与中文
		 */
		foreach ($this->_SEG_PUNCT as $_PUNCT){
			is_array($_PUNCT) && count($_PUNCT) == 2 && $s = str_replace($_PUNCT[0], $_PUNCT[1], $s);
		}
		/**
		 * 按句(每一行)扫描
		 */
		$this->_seg_result = array(); foreach (explode("\n", $s) as $txt){
			$txt = trim($txt); if ($txt != ''){
				$this->seg_count_line++;
				$this->seg_scan($txt);
			}
		}
		/**
		 * 返回结果
		 */
		return $this->_seg_result;
	}
	/**
	 * 临时属性
	 * @property mixed array $_seg_result, 分词结果
	 */
	private $_seg_result = array();
	/**
	 * 分词行扫描(最大分词法)
	 * @param string $s, 待扫描行内容
	 * @return void, 无返回
	 * @note #1: 按顺序抛出最左侧一个字给第#2步
	 *       #2: 按顺序分别提取1, 2, ... n个词组合词汇
	 *       #3: 检查是否为有效词江(词汇在词典存在)
	 */
	private function seg_scan(& $s){
		/**
		 * 段落游标(左抛出)
		 * @note 依次抛出左侧一个字
		 * @example 如"段落游标"->"落游标"->"游标"->标"
		 */
		$ll = mb_strlen($s);
		/**
		 * 待分句子不能长于指定字数
		 */
		if ($ll < $this->_SEG_LEN_MIN){
			return;
		}
		/**
		 * 左抛出
		 */
		for ($li = 0; $li < $ll; $li++){
			$lil = $ll - $li;
			/**
			 * 待分词语不能少于指定字数
			 */
			if ($lil < $this->_SEG_LEN_MIN){
				continue;
			}
			/**
			 * 分词游标(右抛出)
			 */
			$loop = array();
			$lc = mb_substr($s, $li, $lil);
			$rl = mb_strlen($lc); for ($ri = 1; $ri <= $rl; $ri++){
				/**
				 * 分词长度太小
				 */
				if ($ri < $this->_SEG_LEN_MIN){
					continue;
				}
				/**
				 * 分词长度太大
				 */
				if ($this->_SEG_LEN_MAX && $ri > $this->_SEG_LEN_MAX){
					break;
				}
				/**
				 * 切割词语
				 */
				$rc = mb_substr($lc, 0, $ri);
				$loop[] = $rc;
			}
			/**
			 * 最小分法时倒序
			 */
			if ($this->_SEG_MODE === 'MIN'){
				$loop = array_reverse($loop);
			}
			/**
			 * 按句提取
			 */
			$this->seg_seeks($loop);
		}
	}
	/**
	 * 词典检索
	 * @param string $s, 词汇
	 * @return void, 无返回
	 * @note 检查调度, 按模式选择; 如果有效词汇将
	 *       更新到属性_seg_result中
	 */
	private function seg_seeks(& $s){
		switch ($this->_DICT_MODE){
			case 'file' : $this->seg_seeks_file($s); break;
			case 'mysql' : $this->seg_seeks_mysql($s); break;
			case 'redis' : $this->seg_seeks_redis($s); break;
		} $s = array();
	}
	private function seg_seeks_file(& $s){
		/** 以后再说 */
	}
	private function seg_seeks_mysql(& $s){
		foreach ($s as $v){
			$h = $this->_hash($v); $q = wsd::R('cws_dict', array('hash' => $h), true); if (is_array($q) && isset($q['word'])){
				$this->seg_append($q['weight'], $v, $h); if ($this->_SEG_MODE === 'MIN'){
					break;
				}
			}
		}
	}
	private function seg_seeks_redis(& $s){
		foreach ($s as $v){
			$h = $this->_hash($v); $r = nosql::app('get', $this->_DICT_MODE_REDIS.':'.$h); if ($r){
				$this->seg_append($r, $v, $h); if ($this->_SEG_MODE === 'MIN'){
					break;
				}
			}
		}
	}

	/**
	 * 附加分词结果
	 * @param float $r, 权重基数
	 * @param string $w, 词汇
	 * @param string $h, 哈希
	 */
	private function seg_append($r, $w, $h){
		$this->seg_count_seek++; 
		/**
		 * 词频+1
		 */
		if (isset($this->_seg_result[$h])){
			$this->_seg_result[$h]['times']++;
		}
		/**
		 * 加到结果
		 */
		else {
			$this->seg_count_word++; $this->_seg_result[$h] = array(
				'hash' => $h, 
				'times' => 1, 
				'weight' => $r, 
				'word' => $w, 'length' => mb_strlen($w)
			);
		}
	}

	/**
	 * 分词同步
	 * @note 仅在非MySQL模式下有效
	 * @return int, 实际更新总数
	 */
	private $_sync_count = 0;
	public function sync(){
		$this->_sync_count = 0; if ($this->_DICT_MODE == 'mysql'){
			return $this->_sync_count;
		}
		/**
		 * Redis词典
		 */
		$r = wsd::S(array(
			'from' => 'cws_dict'
		), null, array(
			$this, 'sync_'.$this->_DICT_MODE
		)); 
		/**
		 * 更新数量
		 */
		return $this->_sync_count;
	}
	public function sync_file($p){
		/** 以后再说 */
	}
	public function sync_redis($p){
		$this->_sync_count++;
		$this->save_redis($p);
	}

	/**
	 * 词汇唯一哈希值, 保证唯一性
	 * @param string $s, 词汇(如：中国)
	 * @return string, 小写之后的MD5字段(中间16个字符)
	 */
	private function _hash(& $s){
		return substr(md5(strtolower($s)), 8, 16);
	}
}